React
A progressive form in Next.js: it works without JavaScript
A Server Action on action={...} submits before hydration: the browser sends a native POST and useActionState only adds the error state on top.
Key points
- A Server Action passed to `action` renders a `
- Real validation lives in the Zod schema on the server: HTML attributes are only convenience.
- Without JavaScript there is no pending state, so protection against double submits belongs on the server.
Short answer: pass the Server Action to the action prop of the form and validate on the server. Next.js renders an HTML form with the action id in a hidden field, so the POST goes out before hydration; useActionState only adds the error state.
The action, validation included
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
const schema = z.object({
email: z.email(),
message: z.string().min(20, "Almeno 20 caratteri."),
});
export async function subscribe(_prev: unknown, formData: FormData) {
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
const first = parsed.error.issues[0];
return { error: first.message };
}
await saveLead(parsed.data);
redirect("/contatti/grazie");
}
The form, with the extra state
"use client";
import { useActionState } from "react";
import { subscribe } from "./actions";
export default function ContactPage() {
const [state, action, pending] = useActionState(subscribe, null);
return (
{state?.error && {state.error}}
Iscrivimi
);
}
curl -s http://localhost:3000/contatti | grep -o 'ACTION_ID_[0-9a-f]*'
The command prints the id when the form is progressive: that hidden field tells the browser where to send the POST before React starts. If nothing is printed, the form has no Server Action but an onSubmit handler, and without JavaScript it does nothing.
What changes without JavaScript
| Step | With JavaScript | Without JavaScript |
|---|---|---|
| Submit | The Server Action runs from the client | Native browser POST |
| Failed validation | Error shown without a reload | A new page with the error inside |
| Pending state | Button disabled by pending | None: the browser indicator is all you get |
| Final redirect | Client navigation | Server response with the new page |
The rules you cannot see
- The
requiredandminLengthattributes are a courtesy to the user: anyone can send the POST without them. - The Zod schema lives in a
"use server"file and imports nothing from the client. redirectis called outside atry/catch: it throws an internal error the framework has to catch.
Source: the Next.js documentation on Server Actions and forms (MIT licence for the example code) and WCAG 2.2 criterion 3.3.1 on errors identified in text; useActionState is the stable React 19 hook that replaces useFormState.
A named limitation
Without JavaScript there is no pending state: the button never disables and a double click sends two POSTs. Protection belongs on the server, with an idempotency key unique to each submit, because disabling is only interface. A second limit: the page is a client component to use useActionState, so the form is not a pure server component; the POST stays native, but the client paints the form markup after hydration.
Where to continue
In the catalogue you find the shadcn library, a form block with preview and the form category. The project layout is in how to structure an App Router project and validation is in validating a Server Action; the comparison of the two routes is Route Handler versus Server Action. For fields inside the form read a searchable select and calendar and date picker.
Frequently asked questions
Does a form with a Server Action really work without JavaScript?
Yes. Next.js renders the form with the action id in a hidden field, so the browser sends a native POST and receives the new page.
Why does the error not show without JavaScript?
Because `useActionState` is a client hook. Without JavaScript the server answers with the page rendered again, and the error has to be in the returned markup, not only in client state.
Can I keep the page as a server component?
Yes, by moving `useActionState` into a client child component that wraps the form. The server component paints the page, the client owns the state.
Where does validation go, in HTML or in Zod?
In Zod. HTML attributes save a round trip but stop nobody: anyone posting by hand skips them.
Why does a redirect inside a try/catch break everything?
Because `redirect` signals navigation by throwing a special error. A `catch` swallows it and the function returns the wrong value, so the page never moves.