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! 🚀


