Skip to the solution
MediumFrontend5 min read

How do you handle forms with Server Actions in Next.js (progressive enhancement)?

1.2k views

The problem

Question presented to candidate: "Build a signup form with Server Actions. How does it behave before the JavaScript has loaded, and how do you show validation errors and a pending state?"

What a strong answer should cover:

  • Pass the action to <form action={...}>; the action receives a FormData.
  • The server-rendered HTML is a real multipart POST form with a hidden action id, so it submits even before or without JavaScript.
  • After hydration React intercepts the submit, calls the action without a page reload, and keeps client state.
  • useActionState returns the action result (such as validation errors) and a pending flag; useFormStatus gives pending state to a submit button.
  • Validate on the server in the action; client-side validation is a convenience, not a guarantee.

Clarifying questions expected:

  • "Must it work without JavaScript?" β€” then errors must come back through the action result, not client-only state.
  • "Where should the user go after success?" β€” redirect() inside the action.

Code / implementation expected: Yes β€” a form with useActionState and a pending button.

  • nextjs
  • forms
  • server-actions
  • progressive-enhancement

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

5 min read Β· 8 sections

Read the code

A signup form with server errors and a pending button
// app/signup/actions.ts
"use server";
import { redirect } from "next/navigation";
export type SignupState = { errors?: { email?: string }; email?: string };
export async function signup(_prev: SignupState, formData: FormData): Promise<SignupState> {
  const email = String(formData.get("email") ?? "");
  if (!email.includes("@")) return { errors: { email: "Enter a valid email" }, email };
  await db.user.create({ data: { email } });
  redirect("/welcome");
}

// app/signup/SignupForm.tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { signup, type SignupState } from "./actions";

function Submit() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Creating..." : "Sign up"}</button>;
}

export function SignupForm() {
  const [state, formAction] = useActionState<SignupState, FormData>(signup, {});
  return (
    <form action={formAction}>
      <input name="email" type="email" defaultValue={state.email} required />
      {state.errors?.email && <p role="alert">{state.errors.email}</p>}
      <Submit />
    </form>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 19 of 129 in Next.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Next.js questions
Up nextMedium

Intercepting routes β€” how do you build an Instagram-style modal?

Continue