Progressive Enhancement with React 19 Actions
React 19's Server Actions enable a principle called progressive enhancement: your forms work without JavaScript, and JavaScript enhances the experience. This means users with slow connections, broken scripts, or browsers with JS disabled can still submit forms. It's a resilience pattern that has proven critical in production apps.
Progressive enhancement with React 19 is unique because Server Actions are actual HTTP endpoints. When JavaScript fails to load or is disabled, the form submits as a traditional HTML form to the server. When JavaScript loads, React intercepts the submission, handles optimistic updates, and shows pending states. The UX is seamless in both worlds.
How Server Actions Enable Progressive Enhancement
A Server Action is both a JavaScript function and an HTTP endpoint. Here's the magic: <form action={serverAction}> works with or without JavaScript.
Without JavaScript:
- User fills form.
- User clicks submit.
- Browser sends a multipart form request to the server.
- Server runs the action and returns a response.
- Browser navigates or refreshes with the response.
With JavaScript:
- User fills form.
- User clicks submit.
- React intercepts the submission (via the action).
- React sends the FormData as a request to the server.
- Server runs the action and returns the response.
- React updates the UI without a full page navigation.
The code is identical in both cases. This is progressive enhancement.
The Basic Pattern
Here's a form that works without JavaScript:
// app/actions.js
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData) {
const title = formData.get("title");
const content = formData.get("content");
// Validate
if (!title || !content) {
return { error: "All fields required" };
}
// Server-side logic
const post = await db.posts.create({ title, content });
// Revalidate cache (Next.js specific)
revalidatePath("/posts");
// Redirect on success (without JS, this is a page redirect)
return { success: true, post };
}
// app/components/CreatePostForm.js
"use client";
import { useActionState } from "react";
import { createPost } from "../actions";
export default function CreatePostForm() {
const [state, dispatch, pending] = useActionState(createPost, {
error: null,
success: false
});
return (
<form action={dispatch} method="POST">
<div>
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
type="text"
required
disabled={pending}
/>
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
required
disabled={pending}
/>
</div>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
{state.success && <p style={{ color: "green" }}>Post created!</p>}
<button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create Post"}
</button>
</form>
);
}
Without JavaScript:
- Form submits as
POSTto the server. - Server returns a response.
- Browser shows the result.
With JavaScript:
- React intercepts the submission.
- React calls the Server Action.
- React updates the UI with the response.
- No page reload.
Handling Redirects Progressively
When using Server Actions with redirects, handle both scenarios:
"use server";
import { redirect } from "next/navigation";
export async function loginUser(formData) {
const email = formData.get("email");
const password = formData.get("password");
const user = await authenticateUser(email, password);
if (!user) {
return { error: "Invalid credentials" };
}
// Set a cookie or session
setSessionCookie(user.id);
// Without JS: redirect the page
// With JS: return success state
// Next.js redirect() works in both cases
redirect("/dashboard");
}
When redirect() is called:
- With JS: React catches the redirect and navigates using the router.
- Without JS: The server sends a redirect response, and the browser follows it.
Fallback Content for No JavaScript
Show a message to users if JavaScript fails to load:
export default function CreatePostForm() {
const [state, dispatch] = useActionState(createPost, {});
return (
<>
<noscript>
<div style={{ background: "#fef3c7", padding: "1rem", marginBottom: "1rem" }}>
JavaScript is disabled. The form still works, but you won't see live updates.
</div>
</noscript>
<form action={dispatch} method="POST">
{/* Form fields */}
</form>
</>
);
}
The <noscript> element shows only if JavaScript is disabled.
Providing Better UX with Pending State (JS-Only Enhancement)
With JavaScript, you can provide richer feedback:
export default function CreatePostForm() {
const [state, dispatch, pending] = useActionState(createPost, {});
return (
<form action={dispatch}>
<input name="title" disabled={pending} />
<textarea name="content" disabled={pending} />
{/* These only show with JavaScript */}
{pending && <div className="spinner">Saving...</div>}
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">Post created!</p>}
<button disabled={pending}>
{pending ? "Creating..." : "Create"}
</button>
</form>
);
}
Without JavaScript:
- Form submits normally.
- Button is always enabled (no pending state).
- No error/success messages (page refresh shows result).
With JavaScript:
- Form is intercepted.
- Button disables while pending.
- Error/success messages appear inline.
Optimizing for Both Scenarios
Here's a pattern that works well in both cases:
"use server";
export async function submitForm(prevState, formData) {
const data = Object.fromEntries(formData);
// Validation
if (!data.email.includes("@")) {
return { error: "Invalid email", success: false };
}
// Server-side work
try {
const result = await saveToDatabase(data);
// With JS: return success state
// Without JS: you might redirect here
return { success: true, result };
} catch (err) {
return { error: err.message, success: false };
}
}
"use client";
import { useActionState } from "react";
import { submitForm } from "../actions";
export default function Form() {
const [state, dispatch, pending] = useActionState(submitForm, {});
return (
<form action={dispatch}>
<input name="email" type="email" required />
{/* Show errors (with JS) or rely on server response (without JS) */}
{state?.error && (
<p style={{ color: "red" }}>{state.error}</p>
)}
{state?.success && (
<p style={{ color: "green" }}>Saved!</p>
)}
<button disabled={pending}>
{pending ? "Saving..." : "Save"}
</button>
</form>
);
}
This form works in both scenarios:
- With JS: Instant feedback, no page reload, optimized UX.
- Without JS: Standard form submission, page reload with result.
Testing Progressive Enhancement
To test without JavaScript (in Next.js):
# Disable JavaScript in your browser dev tools
# Or use curl to test the endpoint:
curl -X POST https://localhost:3000/api/actions \
-d "title=Test&content=Hello"
The Server Action endpoint should respond with a valid HTTP response (redirect or JSON state).
Real-World: Newsletter Signup
Here's a newsletter signup form that works without JavaScript:
// app/actions.js
"use server";
import { redirect } from "next/navigation";
export async function subscribeNewsletter(prevState, formData) {
const email = formData.get("email");
if (!email || !email.includes("@")) {
return { error: "Valid email required" };
}
try {
// Add to newsletter service
await fetch("https://api.newsletter.com/subscribe", {
method: "POST",
body: JSON.stringify({ email })
});
// With JS: return success
// Without JS: redirect to confirmation
return { success: true };
} catch (err) {
return { error: "Subscription failed" };
}
}
// app/components/NewsletterSignup.js
"use client";
import { useActionState } from "react";
import { subscribeNewsletter } from "../actions";
export default function NewsletterSignup() {
const [state, dispatch, pending] = useActionState(subscribeNewsletter, {});
return (
<form action={dispatch}>
<noscript>
<p>Subscribe to our newsletter. No JavaScript required.</p>
</noscript>
<input
name="email"
type="email"
placeholder="[email protected]"
required
disabled={pending}
autoComplete="email"
/>
<button disabled={pending}>
{pending ? "Subscribing..." : "Subscribe"}
</button>
{state?.error && <p className="error">{state.error}</p>}
{state?.success && <p className="success">Check your email!</p>}
</form>
);
}
Without JavaScript:
- Form submits.
- Page reloads with success/error.
With JavaScript:
- Instant feedback.
- No page reload.
Key Takeaways
- Server Actions enable progressive enhancement: forms work without JavaScript.
- Use
<form action={serverAction}>to make forms work in both scenarios. - Handle both cases: with JS, show pending/error/success states; without JS, rely on page reload.
- Test without JavaScript by disabling it in browser dev tools.
- Progressive enhancement improves resilience, accessibility, and reliability.
Frequently Asked Questions
What if JavaScript fails after loading?
The form degrades gracefully: the submit button still works (sends a regular form request).
How do I know if JavaScript is loaded?
You don't, and you shouldn't rely on it. Build for both scenarios.
Can I use optimistic updates without JavaScript?
No. Optimistic updates require JavaScript to intercept the submission and update the DOM. Without JavaScript, the user sees the page reload.
Should I always build for progressive enhancement?
Ideally yes, but it depends on your audience. If you're building for the open web (all users), yes. If you're building an internal app (controlled browsers), it's less critical.
How do I handle form validation without JavaScript?
Server-side validation is mandatory. Client-side validation is an enhancement. Always validate on the server.