The New Standard for Forms in Next.js 15
Forms used to be hard—not because HTML forms are complicated, but because keeping validation consistent across the stack was. Client-side checks drifted from backend rules, TypeScript types went out of sync, and edge cases slipped through.
Next.js 15 changes this fundamentally.
Why Server Actions Matter
Server Actions allow you to run server-side logic directly from your React components—without creating API routes or manually handling fetch calls. That makes them ideal for form submissions, where validation and side effects naturally belong on the server.
But Server Actions alone aren't enough.
We still need:
- Immediate feedback in the UI
- A single source of truth for validation rules
- Asynchronous checks that go beyond regexes
That's where the rest of the stack comes in.
The Stack at a Glance

Together, they form a clean, scalable pattern that fits perfectly with Next.js 15's architecture. ✨
Step 1: Project Setup and Dependencies
Let's start with the essentials.
From your Next.js 15 project root, install the required dependencies:
npm install react-hook-form zod @hookform/resolvers
These libraries are fully compatible with the current Next.js ecosystem and work well alongside React's latest features.
You'll also want access to AbstractAPI's Email Validation API, which you can learn more about on the AbstractAPI Email Validation page.
This API will be used from our Server Action to validate emails asynchronously.
💡 Tip: Store your Abstract API key in an environment variable (ABSTRACT_API_KEY) and never expose it to the client.
Step 2: Defining the Schema (Your Type Contract)
A robust form starts with a schema. In this pattern, the schema is the contract between your UI and your backend.
Create a new file at: src/schemas/form.ts
Defining the Zod Schema
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters long"),
});
This schema does more than validation—it becomes the backbone of your type system.
Inferring Types from the Schema
export type SignupForm = z.infer<typeof signupSchema>;
This inferred type ensures that:
- Your form fields
- Your Server Action input
- Your validation logic
are all guaranteed to stay in sync.
No duplicated interfaces. No manual typing. No drift. 🧠
Step 3: The Server Action (Where Logic Lives)
This is where Next.js 15 truly shines.
Server Actions let you keep sensitive logic—like API calls and validation—securely on the server, while still being easy to call from your components.
Create a new file: src/actions/signup.ts
And mark it as a Server Action: "use server";
Implementing the Validation Flow
Here's the full logic we want:
- Parse and validate input using Zod
- If Zod passes, call AbstractAPI
- Translate API results into structured form errors
Example Server Action:
"use server";
import { signupSchema, SignupForm } from "@/schemas/form";
type ActionResult = {
success: boolean;
errors?: Partial<Record<keyof SignupForm, string>>;
};
export async function signupAction(
data: SignupForm
): Promise<ActionResult> {
const parsed = signupSchema.safeParse(data);
if (!parsed.success) {
const fieldErrors: ActionResult["errors"] = {};
parsed.error.issues.forEach((issue) => {
const fieldName = issue.path[0] as keyof SignupForm;
fieldErrors[fieldName] = issue.message;
});
return { success: false, errors: fieldErrors };
}
const email = parsed.data.email;
const response = await fetch(
`https://emailvalidation.abstractapi.com/v1/?api_key=${process.env.ABSTRACT_API_KEY}&email=${email}`
);
const result = await response.json();
if (result.is_disposable_email) {
return {
success: false,
errors: {
email: "Disposable email addresses are not allowed",
},
};
}
if (!result.is_valid_format || result.deliverability !== "DELIVERABLE") {
return {
success: false,
errors: {
email: "This email address does not appear to exist",
},
};
}
// At this point, the email is valid and trustworthy
return { success: true };
}
Why This Layer Matters
Zod can tell you whether an email looks valid.
AbstractAPI tells you whether it's actually usable.
This distinction is critical if you want to:
- Prevent disposable signups
- Reduce bounce rates
- Keep your user database clean
You can learn more about these risks in AbstractAPI's guides on preventing disposable emails.
Step 4: The Client Component (The UI)
Now let's wire everything together in the UI.
Create a client component: "use client";
Setting Up React Hook Form with Zod
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, SignupForm } from "@/schemas/form";
import { signupAction } from "@/actions/signup";
export function SignupFormComponent() {
const form = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
});
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = form;
const onSubmit = async (data: SignupForm) => {
const result = await signupAction(data);
if (!result.success && result.errors) {
Object.entries(result.errors).forEach(([field, message]) => {
setError(field as keyof SignupForm, {
message,
});
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
type="email"
placeholder="Email"
{...register("email")}
/>
{errors.email && <p>{errors.email.message}</p>}
<input
type="password"
placeholder="Password"
{...register("password")}
/>
{errors.password && <p>{errors.password.message}</p>}
<button type="submit" disabled={isSubmitting}>
Sign up
</button>
</form>
);
}
UX Tip 🎯
Using form.setError() lets you surface server-side validation errors directly inside the form, exactly where users expect them—without alerts or generic messages.
This creates seamless experience where client and server validation feel like a single system.
Advanced: Async Zod Refinement
Zod supports asynchronous validation via refine, which makes it tempting to call APIs directly from the schema.
For example, you could check an email's validity during validation:
z.string().email().refine(async (email) => {
// async validation
});
But Be Careful ⚠️
Calling an external API:
- On every keystroke
- Or on every validation cycle
is expensive and slow—and can burn through API credits quickly.
Best Practice Recommendation
- Use Zod for syntax and structure
- Use Server Actions + AbstractAPI for deep validation
- Optionally trigger async checks on blur, not on change
This keeps your app fast, scalable, and cost-efficient.
Handling Pending States in Next.js 15
Next.js 15 introduces useActionState (formerly useFormState) for handling Server Action state transitions.
While it's powerful, for this pattern React Hook Form's isSubmitting flag is usually simpler and clearer, especially when you're already managing form state locally.
Use useActionState when:
- You rely heavily on native <form action={...}>
- You want framework-level pending handling
Otherwise, stick with what React Hook Form already gives you. 👍
Final Thoughts: A Future-Proof Form Stack
With this setup, you now have a form that:

Most importantly, you've eliminated an entire class of bugs caused by mismatched validation logic.
Ready to Secure Your Forms?
Don't let fake users, disposable emails, or invalid data creep into your database.
👉 Get your free Abstract API key and start protecting your Next.js forms with real-world validation today:
Happy coding! 🚀
Frequently Asked Questions
What is type-safe form validation in Next.js 15 and why does it matter?
Type-safe form validation means your validation rules, TypeScript types, and runtime checks all come from a single source of truth (a Zod schema) rather than being duplicated across client and server code. In Next.js 15, this matters because forms span both client components and Server Actions, and without a shared schema your client-side checks can silently drift out of sync with your backend rules.
How do Zod and React Hook Form work together in Next.js 15?
You define a Zod schema that describes the shape and rules of your form data, then pass it to React Hook Form via zodResolver from the @hookform/resolvers package. React Hook Form uses that schema to validate field values on the client in real time, while the same Zod schema can also run in your Server Action to validate the incoming request on the server, with no logic duplication required.
When should I use a Next.js 15 Server Action for form validation instead of handling everything on the client?
Use a Server Action when the validation requires something the client cannot safely do, such as checking a database, calling a third-party API, or validating sensitive business rules. For example, checking whether an email address is deliverable via a service like Abstract's Email Validation should happen inside a Server Action, not in a client-side Zod refine callback that would expose your API key and fire on every keystroke.
Why shouldn't I put async email validation inside Zod's refine method on the client?
Calling an external API inside refine on the client is expensive and slow: it would fire on every validation trigger (including each keystroke if validation is set to onChange), exposing your API key in the browser and hammering the external service. The recommended pattern is to keep synchronous format checks in Zod's refine, and reserve async checks like SMTP or disposability lookups for the Server Action or an optional onBlur event handler.
What does Abstract's Email Validation check that a regex cannot?
A regex can only confirm that an email address is formatted correctly. Abstract's Email Validation goes further by checking whether the domain has valid MX records, whether the mailbox actually exists via an SMTP handshake, and whether the address belongs to a disposable or temporary email provider. These checks catch addresses that look valid but would bounce or belong to throwaway accounts, which a regex alone cannot detect.
How do I infer TypeScript types from a Zod schema in Next.js 15?
Use z.infer<typeof YourSchema> to derive a TypeScript type directly from the schema. You can then use that type in your React Hook Form's useForm<YourType> call, in your Server Action's parameter type, and anywhere else in the codebase that handles that data. This ensures your types never drift from your validation rules because they are generated from the same definition.


