Progressive Enhancement with the form Action Prop
Wire forms to Server Actions so they work without JavaScript and enhance once hydrated.
Progressive Enhancement with the form Action Prop is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Forms That Work Without JavaScript
Progressive enhancement means a form works using plain HTML first, then gets better once JavaScript loads and React hydrates.
In Next.js 15 App Router, you achieve this by passing a Server Action directly to a form's action prop. The browser can submit the form natively to the server before any client JS arrives.
- No JS yet? The form still posts and the server responds.
- JS hydrated? React intercepts the submit and uses
fetchunder the hood, avoiding a full page reload.
What a Server Action Looks Like
A Server Action is an async function marked with the 'use server' directive. It runs only on the server.
When passed to <form action={...}>, Next.js wires it up so the form's fields arrive as FormData.
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const body = formData.get('body') as string
// ...persist to your database here
console.log('Saving:', { title, body })
}Wiring the Action to the Form
Pass the Server Action straight into the action prop. Because this is real HTML, each input needs a name attribute so it shows up in FormData.
This component can be a Server Component — no 'use client' needed for the basic case.
// app/new/page.tsx
import { createPost } from '../actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="body" placeholder="Body" />
<button type="submit">Publish</button>
</form>
)
}Why the name Attribute Matters
Native form submission serializes inputs by their name. Next.js builds the FormData object from those names — both when JS is off (native POST) and when it is on (intercepted fetch).
formData.get('title')reads the input namedtitle.- An input without a
nameis invisible to the server. - This is the same contract as classic HTML forms, which is exactly why it degrades gracefully.
The No-JavaScript Path
Disable JavaScript in your browser and submit the form. It still works.
Here is what happens on that path:
- The browser performs a standard HTTP POST to the current route.
- Next.js runs your Server Action on the server with the submitted
FormData. - If the action calls
redirect()orrevalidatePath(), the server responds with the updated page.
This is the baseline that progressive enhancement guarantees.
Redirect and Revalidate After Submit
After a successful mutation you usually want to refresh cached data and navigate. Both work on the no-JS path and the hydrated path.
revalidatePath('/posts')tells Next.js to refetch that route's data.redirect('/posts')sends the user to the new location.
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await savePost({ title })
revalidatePath('/posts')
redirect('/posts')
}Validating FormData on the Server
Never trust client input. Validate inside the Server Action so the rules apply even when JavaScript is disabled.
A clean approach is to parse FormData with a schema and return a typed result the form can display.
// app/actions.ts
'use server'
import { z } from 'zod'
const schema = z.object({
title: z.string().min(3, 'Title too short'),
})
export async function createPost(formData: FormData) {
const parsed = schema.safeParse({
title: formData.get('title'),
})
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await savePost(parsed.data)
return { error: null }
}Enhancing with useFormStatus
Once hydrated, you can show pending UI. The useFormStatus hook reports whether the parent form is submitting.
It must be called from a child component of the form, inside a Client Component. When JS is off, this code simply doesn't run, and the plain button is what the user sees.
// app/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Publishing...' : 'Publish'}
</button>
)
}Returning State with useActionState
To surface validation errors and success messages, wrap the action with useActionState (React 19 / Next.js 15). It threads the action's return value back into your component as state.
The form still works without JS: the same action runs on a native POST, just without the in-place state update.
// app/post-form.tsx
'use client'
import { useActionState } from 'react'
import { createPost } from './actions'
export function PostForm() {
const [state, formAction] = useActionState(createPost, { error: null })
return (
<form action={formAction}>
<input name="title" />
{state.error?.title && <p>{state.error.title[0]}</p>}
<button type="submit">Publish</button>
</form>
)
}Matching the Action Signature
When you use useActionState, the Server Action gains an extra first argument: the previous state. The signature changes from (formData) to (prevState, formData).
Keep the return shape consistent so the initial state and every result line up.
// app/actions.ts
'use server'
type State = { error: string | null }
export async function createPost(
prevState: State,
formData: FormData,
): Promise<State> {
const title = formData.get('title') as string
if (!title) return { error: 'Title is required' }
await savePost({ title })
return { error: null }
}Keeping the No-JS Path Healthy
Progressive enhancement only holds if you avoid breaking the native path. Watch out for:
- Relying on
onClickoronChangehandlers for required behavior — those need JS. - Submitting without a
type="submit"button. - Reading values from React state instead of
FormDatain the action.
Rule of thumb: the action and inputs carry the contract; client hooks only enhance it. Test by disabling JS at least once.
Quick Check
Test your understanding of how a Server Action enables progressive enhancement.
Recap
You learned how to build progressively enhanced forms in Next.js 15:
- Pass a Server Action to
<form action={...}>so it works via native POST before hydration. - Give every input a
name— that is the contract that feedsFormDataon both paths. - Validate on the server so rules apply even with JS disabled.
- Use
redirectandrevalidatePathto update the UI after a mutation. - Enhance with
useFormStatus(pending UI) anduseActionState(returned state), remembering the action signature becomes(prevState, formData). - Keep required behavior in the action and inputs, not in client-only handlers, and test with JavaScript off.
Frequently asked questions
Is the “Progressive Enhancement with the form Action Prop” lesson free?
Yes — the full text of “Progressive Enhancement with the form Action Prop” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.
What will I learn in “Progressive Enhancement with the form Action Prop”?
Wire forms to Server Actions so they work without JavaScript and enhance once hydrated. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?
No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Progressive Enhancement with the form Action Prop” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?
Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Progressive Enhancement with the form Action Prop
- Pending and Loading States with useFormStatus
- Field-Level Validation Errors with useActionState
- Instant Feedback Using useOptimistic